// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * A component for displaying and interacting with a node-based graph.
 *
 * Features:
 * - Draggable, selectable, and removable nodes.
 * - Pannable and zoomable canvas.
 * - Connectable ports to create links between nodes.
 * - Docking nodes to each other to form chains.
 * - Customizable node content and appearance.
 * - Auto-layout and fit-to-screen functionality.
 *
 * Minimal example:
 *
 * ```typescript
 * const nodes: Node[] = [
 *   {id: 'node1', x: 50, y: 50, outputs: [{direction: 'right'}]},
 *   {id: 'node2', x: 250, y: 50, inputs: [{direction: 'left'}]},
 * ];
 *
 * const connections: Connection[] = [
 *   {fromNode: 'node1', fromPort: 0, toNode: 'node2', toPort: 0},
 * ];
 *
 * m(NodeGraph, {
 *   nodes,
 *   connections,
 *   onConnect: (newConnection) => {
 *     // Handle new connection
 *   },
 *   onNodeMove: (nodeId, x, y) => {
 *     // Handle node position change (called when node is dropped)
 *   },
 * });
 * ```
 */
import m from 'mithril';
import {Button, ButtonVariant} from './button';
import {PopupMenu} from './menu';
import {classNames} from '../base/classnames';
import {Icons} from '../base/semantic_icons';

interface Position {
  x: number;
  y: number;
  transformedX?: number;
  transformedY?: number;
}

export interface Connection {
  readonly fromNode: string;
  readonly fromPort: number;
  readonly toNode: string;
  readonly toPort: number;
}

export interface NodeTitleBar {
  readonly title: m.Children;
}

export interface NodePort {
  readonly content?: m.Children;
  readonly direction: 'top' | 'left' | 'right' | 'bottom';
  readonly contextMenuItems?: m.Children;
}

export type DockedNode = Omit<Node, 'x' | 'y'>;

export interface Node {
  readonly id: string;
  readonly x: number;
  readonly y: number;
  readonly hue?: number; // Color of the title / accent bar (0-360)
  readonly accentBar?: boolean; // Optional strip of accent color on the left side (doesn't work well with titleBar)
  readonly titleBar?: NodeTitleBar; // Optional title bar (doesn't work well with accentBar or docking)
  readonly inputs?: ReadonlyArray<NodePort>;
  readonly outputs?: ReadonlyArray<NodePort>;
  readonly content?: m.Children; // Optional custom content to render in node body
  readonly next?: DockedNode; // Next node in chain
  readonly canDockTop?: boolean;
  readonly canDockBottom?: boolean;
  readonly contextMenuItems?: m.Children;
  readonly invalid?: boolean; // Whether this node is in an invalid state
}

interface ConnectingState {
  nodeId: string;
  portIndex: number;
  type: 'input' | 'output';
  portType: 'top' | 'bottom' | 'left' | 'right';
  x: number;
  y: number;
  transformedX: number;
  transformedY: number;
}

interface UndockCandidate {
  nodeId: string;
  parentId: string;
  startX: number;
  startY: number;
  renderY: number;
}

interface UndockedNode {
  nodeId: string;
  parentId: string;
}

interface SelectionRect {
  startX: number;
  startY: number;
  currentX: number;
  currentY: number;
}

interface CanvasState {
  draggedNode: string | null;
  dragOffset: Position;
  connecting: ConnectingState | null;
  mousePos: Position;
  selectedNodes: ReadonlySet<string>;
  panOffset: Position;
  isPanning: boolean;
  panStart: Position;
  zoom: number;
  dockTarget: string | null; // Node being targeted for docking
  isDockZone: boolean; // Whether we're in valid dock position
  undockCandidate: UndockCandidate | null; // Tracks potential undock before threshold
  undockedNode: UndockedNode | null; // Node that was undocked (set when threshold exceeded)
  hoveredPort: {
    nodeId: string;
    portIndex: number;
    type: 'input' | 'output';
  } | null;
  selectionRect: SelectionRect | null; // Box selection state
  canvasMouseDownPos: Position;
  tempNodePositions: Map<string, Position>; // Temporary positions during drag
}

export interface NodeGraphApi {
  autoLayout: () => void;
  recenter: () => void;
  findPlacementForNode: (node: Omit<Node, 'x' | 'y'>) => Position;
}

export interface NodeGraphAttrs {
  readonly nodes: ReadonlyArray<Node>;
  readonly connections: ReadonlyArray<Connection>;
  readonly onConnect?: (connection: Connection) => void;
  readonly onNodeMove?: (nodeId: string, x: number, y: number) => void;
  readonly onConnectionRemove?: (index: number) => void;
  readonly onReady?: (api: NodeGraphApi) => void;
  readonly selectedNodeIds?: ReadonlySet<string>;
  readonly onNodeSelect?: (nodeId: string) => void;
  readonly onNodeAddToSelection?: (nodeId: string) => void;
  readonly onNodeRemoveFromSelection?: (nodeId: string) => void;
  readonly onSelectionClear?: () => void;
  readonly onDock?: (
    parentId: string,
    childNode: Omit<Node, 'x' | 'y'>,
  ) => void;
  readonly onUndock?: (
    parentId: string,
    nodeId: string,
    x: number,
    y: number,
  ) => void;
  readonly onNodeRemove?: (nodeId: string) => void;
  readonly hideControls?: boolean;
  readonly multiselect?: boolean; // Enable multi-node selection (default: true)
  readonly contextMenuOnHover?: boolean; // Show context menu on hover (default: false)
  readonly fillHeight?: boolean;
  readonly toolbarItems?: m.Children;
  readonly style?: Partial<CSSStyleDeclaration>;
}

const UNDOCK_THRESHOLD = 5; // Pixels to drag before undocking

function isPortConnected(
  nodeId: string,
  portType: 'input' | 'output',
  portIndex: number,
  connections: ReadonlyArray<Connection>,
): boolean {
  return connections.some((conn) => {
    if (portType === 'input') {
      return conn.toNode === nodeId && conn.toPort === portIndex;
    } else {
      return conn.fromNode === nodeId && conn.fromPort === portIndex;
    }
  });
}

// Get the entire chain starting from a root node
function getChain(rootNode: Node): Array<Node | Omit<Node, 'x' | 'y'>> {
  const chain: Array<Node | Omit<Node, 'x' | 'y'>> = [rootNode];
  let current = rootNode.next;

  while (current) {
    chain.push(current);
    current = current.next;
  }

  return chain;
}

function createCurve(
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  fromPortType?: 'top' | 'bottom' | 'left' | 'right',
  toPortType?: 'top' | 'bottom' | 'left' | 'right',
  shortenEnd = 0,
): string {
  const dx = x2 - x1;
  const dy = y2 - y1;
  const distance = Math.sqrt(dx * dx + dy * dy);

  let cx1: number;
  let cy1: number;
  let cx2: number;
  let cy2: number;

  if (shortenEnd > 0) {
    if (toPortType === 'bottom') {
      y2 += shortenEnd;
    } else if (toPortType === 'top') {
      y2 -= shortenEnd;
    } else if (toPortType === 'left') {
      x2 -= shortenEnd;
    } else if (toPortType === 'right') {
      x2 += shortenEnd;
    }
  }

  // For top/bottom ports, control points extend vertically
  // For left/right ports, control points extend horizontally
  if (fromPortType === 'bottom' || fromPortType === 'top') {
    // First control point extends vertically
    const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
    cx1 = x1;
    cy1 = fromPortType === 'bottom' ? y1 + verticalOffset : y1 - verticalOffset;
  } else {
    // First control point extends horizontally for left/right ports
    const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
    cx1 = x1 + horizontalOffset;
    cy1 = y1; // Keep Y constant for horizontal extension
  }

  if (toPortType === 'bottom' || toPortType === 'top') {
    // Second control point extends vertically
    const verticalOffset = Math.max(Math.abs(dy) * 0.5, distance * 0.5);
    cx2 = x2;
    cy2 = toPortType === 'bottom' ? y2 + verticalOffset : y2 - verticalOffset;
  } else {
    // Second control point extends horizontally for left/right ports
    const horizontalOffset = Math.max(Math.abs(dx) * 0.5, distance * 0.5);
    cx2 = x2 - horizontalOffset;
    cy2 = y2; // Keep Y constant for horizontal extension
  }

  // if (shortenEnd > 0) {
  //   const tangentX = x2 - cx2;
  //   const tangentY = y2 - cy2;
  //   const tangentLength = Math.sqrt(tangentX * tangentX + tangentY * tangentY);
  //   if (tangentLength > shortenEnd) {
  //     const unitTangentX = tangentX / tangentLength;
  //     const unitTangentY = tangentY / tangentLength;
  //     x2 -= unitTangentX * shortenEnd;
  //     y2 -= unitTangentY * shortenEnd;
  //   }
  // }

  return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
}

export function NodeGraph(): m.Component<NodeGraphAttrs> {
  const canvasState: CanvasState = {
    draggedNode: null,
    dragOffset: {x: 0, y: 0},
    connecting: null,
    mousePos: {x: 0, y: 0},
    selectedNodes: new Set<string>(),
    panOffset: {x: 0, y: 0},
    isPanning: false,
    panStart: {x: 0, y: 0},
    zoom: 1.0,
    dockTarget: null,
    isDockZone: false,
    undockCandidate: null,
    undockedNode: null,
    hoveredPort: null,
    selectionRect: null,
    canvasMouseDownPos: {x: 0, y: 0},
    tempNodePositions: new Map<string, Position>(),
  };

  // Track drag state for batching updates
  let dragStartPosition: {nodeId: string; x: number; y: number} | null = null;
  let currentDragPosition: {x: number; y: number} | null = null;

  let latestVnode: m.Vnode<NodeGraphAttrs> | null = null;
  let canvasElement: HTMLElement | null = null;

  const handleMouseMove = (e: PointerEvent) => {
    m.redraw();
    if (!latestVnode || !canvasElement) return;
    const vnode = latestVnode;
    const canvas = canvasElement;
    const canvasRect = canvas.getBoundingClientRect();

    // Store both screen and transformed coordinates
    canvasState.mousePos = {
      x: e.clientX,
      y: e.clientY,
      transformedX:
        (e.clientX - canvasRect.left - canvasState.panOffset.x) /
        canvasState.zoom,
      transformedY:
        (e.clientY - canvasRect.top - canvasState.panOffset.y) /
        canvasState.zoom,
    };

    // Track hovered port (useful for connection snapping and visual feedback)
    const portElement = (e.target as HTMLElement).closest('.pf-port.pf-input');
    if (portElement) {
      const nodeElement = portElement.closest(
        '[data-node]',
      ) as HTMLElement | null;
      const portId =
        portElement.getAttribute('data-port') ||
        portElement.parentElement?.getAttribute('data-port');

      if (nodeElement && portId) {
        const nodeId = nodeElement.dataset.node!;
        const [type, portIndexStr] = portId.split('-');
        if (type === 'input') {
          const portIndex = parseInt(portIndexStr, 10);
          canvasState.hoveredPort = {nodeId, portIndex, type: 'input'};
        } else {
          canvasState.hoveredPort = null;
        }
      } else {
        canvasState.hoveredPort = null;
      }
    } else {
      canvasState.hoveredPort = null;
    }

    if (canvasState.selectionRect) {
      // Update selection rectangle
      canvasState.selectionRect.currentX =
        canvasState.mousePos.transformedX ?? 0;
      canvasState.selectionRect.currentY =
        canvasState.mousePos.transformedY ?? 0;
      m.redraw();
    } else if (canvasState.isPanning) {
      // Pan the canvas
      const dx = e.clientX - canvasState.panStart.x;
      const dy = e.clientY - canvasState.panStart.y;
      canvasState.panOffset = {
        x: canvasState.panOffset.x + dx,
        y: canvasState.panOffset.y + dy,
      };
      canvasState.panStart = {x: e.clientX, y: e.clientY};
      m.redraw();
    } else if (canvasState.undockCandidate !== null) {
      // Check if we've exceeded the undock threshold
      const dx = e.clientX - canvasState.undockCandidate.startX;
      const dy = e.clientY - canvasState.undockCandidate.startY;
      const distance = Math.sqrt(dx * dx + dy * dy);

      if (distance > UNDOCK_THRESHOLD) {
        // Exceeded threshold - call onUndock immediately so node becomes independent
        const {onUndock} = vnode.attrs;
        const tempX =
          (canvasState.undockCandidate.startX -
            canvasRect.left -
            canvasState.panOffset.x) /
            canvasState.zoom -
          canvasState.dragOffset.x / canvasState.zoom;
        const tempY = canvasState.undockCandidate.renderY;

        // Store temp position for this node
        canvasState.tempNodePositions.set(canvasState.undockCandidate.nodeId, {
          x: tempX,
          y: tempY,
        });

        // Immediately call onUndock so the node becomes independent
        if (onUndock) {
          onUndock(
            canvasState.undockCandidate.parentId,
            canvasState.undockCandidate.nodeId,
            tempX,
            tempY,
          );
        }

        // Mark as undocked so we track it as a regular drag now
        canvasState.undockedNode = {
          nodeId: canvasState.undockCandidate.nodeId,
          parentId: canvasState.undockCandidate.parentId,
        };

        canvasState.undockCandidate = null;
        m.redraw(); // Force update so nodes array regenerates
      }
    } else if (canvasState.draggedNode !== null) {
      // Calculate new position relative to canvas container (accounting for pan and zoom)
      const newX =
        (e.clientX - canvasRect.left - canvasState.panOffset.x) /
          canvasState.zoom -
        canvasState.dragOffset.x / canvasState.zoom;
      const newY =
        (e.clientY - canvasRect.top - canvasState.panOffset.y) /
          canvasState.zoom -
        canvasState.dragOffset.y / canvasState.zoom;

      // Store current position internally
      currentDragPosition = {x: newX, y: newY};
      canvasState.tempNodePositions.set(canvasState.draggedNode, {
        x: newX,
        y: newY,
      });

      // Check if we're in a dock zone (exclude the parent we just undocked from)
      const {nodes} = vnode.attrs;
      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
      if (draggedNode) {
        const dockInfo = findDockTarget(draggedNode, newX, newY, nodes);
        canvasState.dockTarget = dockInfo.targetNodeId;
        canvasState.isDockZone = dockInfo.isValidZone;
      }
      m.redraw();
    }
  };

  const handleMouseUp = () => {
    if (!latestVnode) return;
    const vnode = latestVnode;

    // Handle box selection completion
    if (canvasState.selectionRect) {
      const {nodes = []} = vnode.attrs;
      const rect = canvasState.selectionRect;
      const minX = Math.min(rect.startX, rect.currentX);
      const maxX = Math.max(rect.startX, rect.currentX);
      const minY = Math.min(rect.startY, rect.currentY);
      const maxY = Math.max(rect.startY, rect.currentY);

      // Helper to check if a node at given position overlaps with selection rectangle
      const nodeOverlapsRect = (
        nodeX: number,
        nodeY: number,
        nodeId: string,
      ): boolean => {
        const dims = getNodeDimensions(nodeId);
        const nodeRight = nodeX + dims.width;
        const nodeBottom = nodeY + dims.height;

        return (
          nodeX < maxX && nodeRight > minX && nodeY < maxY && nodeBottom > minY
        );
      };

      // Find all nodes (including chained/docked nodes) that intersect with the selection rectangle
      const selectedInRect: string[] = [];
      nodes.forEach((node) => {
        // Check root node
        if (nodeOverlapsRect(node.x, node.y, node.id)) {
          selectedInRect.push(node.id);
        }

        // Check all chained nodes
        const chain = getChain(node);
        let currentY = node.y;
        chain.slice(1).forEach((chainNode) => {
          // For chained nodes, calculate their Y position
          const previousNodeId = chain[chain.indexOf(chainNode) - 1].id;
          currentY += getNodeDimensions(previousNodeId).height;

          if (nodeOverlapsRect(node.x, currentY, chainNode.id)) {
            selectedInRect.push(chainNode.id);
          }
        });
      });

      // Add all selected nodes to selection
      const {onNodeAddToSelection} = vnode.attrs;
      selectedInRect.forEach((nodeId) => {
        if (!canvasState.selectedNodes.has(nodeId)) {
          if (onNodeAddToSelection !== undefined) {
            onNodeAddToSelection(nodeId);
          }
        }
      });

      canvasState.selectionRect = null;
      m.redraw();
      return;
    }

    // Handle docking if in dock zone
    if (
      canvasState.draggedNode &&
      canvasState.isDockZone &&
      canvasState.dockTarget
    ) {
      const {nodes = [], onDock} = vnode.attrs;
      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);
      if (onDock && draggedNode) {
        // Create child node without x/y coordinates
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const {x, y, ...childNode} = draggedNode;
        onDock(canvasState.dockTarget, childNode);
      }
    }

    // Check for collision and finalize drag (only for non-docked/undocked nodes)
    if (canvasState.draggedNode !== null && !canvasState.isDockZone) {
      const {nodes = [], onNodeMove} = vnode.attrs;
      const draggedNode = nodes.find((n) => n.id === canvasState.draggedNode);

      // Only do overlap checking if NOT being docked
      if (draggedNode) {
        // Get actual node dimensions from DOM
        const dims = getNodeDimensions(draggedNode.id);

        // Calculate total height of the dragged node's chain
        const chain = getChain(draggedNode);
        let chainHeight = 0;
        chain.forEach((chainNode) => {
          chainHeight += getNodeDimensions(chainNode.id).height;
        });

        // Check if node (and its entire chain) overlaps with any other nodes
        if (
          currentDragPosition &&
          checkNodeOverlap(
            currentDragPosition.x,
            currentDragPosition.y,
            draggedNode.id,
            nodes,
            dims.width,
            chainHeight,
          )
        ) {
          // Find nearest non-overlapping position
          const newPos = findNearestNonOverlappingPosition(
            currentDragPosition.x,
            currentDragPosition.y,
            draggedNode.id,
            nodes,
            dims.width,
            chainHeight,
          );
          // Update to the non-overlapping position
          currentDragPosition = newPos;
          canvasState.tempNodePositions.set(draggedNode.id, newPos);
        }
      }

      // Call onNodeMove with final position if it changed
      // For undocked nodes, this provides the final position after dragging
      // For regular nodes, this is the only position update
      if (onNodeMove !== undefined && currentDragPosition !== null) {
        const startX = dragStartPosition?.x ?? 0;
        const startY = dragStartPosition?.y ?? 0;
        const moved =
          Math.abs(currentDragPosition.x - startX) > 0.5 ||
          Math.abs(currentDragPosition.y - startY) > 0.5;
        if (moved || canvasState.undockedNode !== null) {
          onNodeMove(
            canvasState.draggedNode,
            currentDragPosition.x,
            currentDragPosition.y,
          );
        }
      }
    }

    canvasState.draggedNode = null;
    dragStartPosition = null;
    currentDragPosition = null;
    canvasState.connecting = null;
    canvasState.hoveredPort = null;
    canvasState.isPanning = false;
    canvasState.dockTarget = null;
    canvasState.isDockZone = false;
    canvasState.undockCandidate = null;
    canvasState.undockedNode = null;
    canvasState.tempNodePositions.clear();
    m.redraw();
  };

  // Helper to determine port type based on port index
  function getPortType(
    nodeId: string,
    portType: 'input' | 'output',
    portIndex: number,
    nodes: ReadonlyArray<Node>,
  ): 'top' | 'bottom' | 'left' | 'right' {
    // Search in main nodes array
    let node: Node | Omit<Node, 'x' | 'y'> | undefined = nodes.find(
      (n) => n.id === nodeId,
    );

    // If not found, search in the next chains of all nodes
    if (!node) {
      for (const rootNode of nodes) {
        let current = rootNode.next;
        while (current) {
          if (current.id === nodeId) {
            node = current;
            break;
          }
          current = current.next;
        }
        if (node) break;
      }
    }

    if (!node) return portType === 'input' ? 'left' : 'right';

    // Get the port from the node
    const ports = portType === 'input' ? node.inputs : node.outputs;
    if (!ports || portIndex >= ports.length) {
      return portType === 'input' ? 'left' : 'right';
    }

    return ports[portIndex].direction;
  }

  function renderConnections(
    svg: SVGElement,
    connections: ReadonlyArray<Connection>,
    nodes: ReadonlyArray<Node>,
    onConnectionRemove?: (index: number) => void,
  ) {
    const shortenLength = 16;
    const arrowheadLength = 4;

    // Cache all port positions at once for performance
    const portPositionCache = new Map<string, Position>();

    // Query all ports in one go and cache their positions
    const allPorts = document.querySelectorAll('.pf-port[data-port]');
    allPorts.forEach((portElement) => {
      const portId = portElement.getAttribute('data-port');
      if (!portId) return;

      const nodeElement = portElement.closest(
        '[data-node]',
      ) as HTMLElement | null;
      if (!nodeElement) return;

      const nodeId = nodeElement.dataset.node;
      if (!nodeId) return;

      const [portType, portIndexStr] = portId.split('-');
      const cacheKey = `${nodeId}-${portType}-${portIndexStr}`;

      // Calculate position
      const chainContainer = nodeElement.closest(
        '.pf-node-wrapper',
      ) as HTMLElement | null;

      let nodeLeft: number;
      let nodeTop: number;

      if (chainContainer) {
        // Node is in a dock chain - use container's position
        nodeLeft = parseFloat(chainContainer.style.left) || 0;
        nodeTop = parseFloat(chainContainer.style.top) || 0;

        // Add offset of node within the chain
        const chainRect = chainContainer.getBoundingClientRect();
        const nodeRect = nodeElement.getBoundingClientRect();
        const offsetY = (nodeRect.top - chainRect.top) / canvasState.zoom;

        nodeTop += offsetY;
      } else {
        // Standalone node - use its position directly
        nodeLeft = parseFloat(nodeElement.style.left) || 0;
        nodeTop = parseFloat(nodeElement.style.top) || 0;
      }

      // Get port's position relative to the node
      const portRect = portElement.getBoundingClientRect();
      const nodeRect = nodeElement.getBoundingClientRect();

      // Calculate offset in screen space, then divide by zoom to get canvas content space
      const portX =
        (portRect.left - nodeRect.left + portRect.width / 2) / canvasState.zoom;
      const portY =
        (portRect.top - nodeRect.top + portRect.height / 2) / canvasState.zoom;

      portPositionCache.set(cacheKey, {
        x: nodeLeft + portX,
        y: nodeTop + portY,
      });
    });

    // Helper function to get port position from cache or fallback to direct lookup
    const getPortPos = (
      nodeId: string,
      portType: 'input' | 'output',
      portIndex: number,
    ): Position => {
      const cacheKey = `${nodeId}-${portType}-${portIndex}`;
      return (
        portPositionCache.get(cacheKey) ||
        getPortPosition(nodeId, portType, portIndex)
      );
    };

    // Build arrowhead markers using mithril
    const arrowheadMarker = (id: string) =>
      m(
        'marker',
        {
          id,
          viewBox: `0 0 ${arrowheadLength} 10`,
          refX: '0',
          refY: '5',
          markerWidth: `${arrowheadLength}`,
          markerHeight: '10',
          orient: 'auto',
        },
        m('polygon', {
          points: `0 2.5, ${arrowheadLength} 5, 0 7.5`,
          fill: 'context-stroke',
        }),
      );

    // Build connection paths using mithril
    // Each connection is rendered as two paths: a wider invisible hitbox and the visible line
    const connectionPaths = connections
      .map((conn, idx) => {
        const from = getPortPos(conn.fromNode, 'output', conn.fromPort);
        const to = getPortPos(conn.toNode, 'input', conn.toPort);

        // Validate that both ports exist (return {x: 0, y: 0} if not found)
        const fromValid = from.x !== 0 || from.y !== 0;
        const toValid = to.x !== 0 || to.y !== 0;

        if (!fromValid || !toValid) {
          console.warn(
            `Invalid connection: ${conn.fromNode}:${conn.fromPort} -> ${conn.toNode}:${conn.toPort}`,
            !fromValid ? `(source port not found)` : `(target port not found)`,
          );
          return null;
        }

        const fromPortType = getPortType(
          conn.fromNode,
          'output',
          conn.fromPort,
          nodes,
        );
        const toPortType = getPortType(
          conn.toNode,
          'input',
          conn.toPort,
          nodes,
        );

        const pathData = createCurve(
          from.x,
          from.y,
          to.x,
          to.y,
          fromPortType,
          toPortType,
          shortenLength,
        );

        const handlePointerDown = (e: PointerEvent) => {
          e.stopPropagation();
          e.preventDefault();
        };

        const handleClick = (e: Event) => {
          e.stopPropagation();
          if (onConnectionRemove !== undefined) {
            onConnectionRemove(idx);
          }
        };

        // Return a group with both the hitbox and visible path
        return m('g', {key: `conn-${idx}`, class: 'pf-connection-group'}, [
          // Invisible wider hitbox path
          m('path', {
            d: pathData,
            class: 'pf-connection-hitbox',
            style: {
              stroke: 'transparent',
              strokeWidth: '20',
              fill: 'none',
              pointerEvents: 'stroke',
              cursor: 'pointer',
            },
            onpointerdown: handlePointerDown,
            onclick: handleClick,
          }),
          // Visible connection path
          m('path', {
            'd': pathData,
            'class': 'pf-connection',
            'marker-end': 'url(#arrowhead)',
            'style': {
              pointerEvents: 'none',
            },
            'onpointerdown': handlePointerDown,
            'onclick': handleClick,
          }),
        ]);
      })
      .filter((path) => path !== null);

    // Build temp connection if connecting
    let tempConnectionPath = null;
    if (canvasState.connecting) {
      const fromX = canvasState.connecting.transformedX;
      const fromY = canvasState.connecting.transformedY;
      let toX = canvasState.mousePos.transformedX ?? 0;
      let toY = canvasState.mousePos.transformedY ?? 0;

      const fromPortType = canvasState.connecting.portType;
      let toPortType: 'top' | 'left' | 'right' | 'bottom' =
        fromPortType === 'top' || fromPortType === 'bottom' ? 'top' : 'left';

      if (
        canvasState.hoveredPort &&
        canvasState.connecting.type === 'output' &&
        canvasState.hoveredPort.type === 'input'
      ) {
        const {nodeId, portIndex, type} = canvasState.hoveredPort;
        const hoverPos = getPortPos(nodeId, type, portIndex);
        if (hoverPos.x !== 0 || hoverPos.y !== 0) {
          toX = hoverPos.x;
          toY = hoverPos.y;
          toPortType = getPortType(nodeId, type, portIndex, nodes);
        }
      }

      tempConnectionPath = m('path', {
        'class': 'pf-temp-connection',
        'd': createCurve(
          fromX,
          fromY,
          toX,
          toY,
          fromPortType,
          toPortType,
          shortenLength,
        ),
        'marker-end': 'url(#arrowhead)',
      });
    }

    // Render everything using mithril's render function
    m.render(svg, [
      m('defs', [arrowheadMarker('arrowhead')]),
      m('g', connectionPaths),
      tempConnectionPath,
    ]);
  }

  function getPortPosition(
    nodeId: string,
    portType: 'input' | 'output',
    portIndex: number,
  ): Position {
    // For port index 0 (top/bottom), data-port is on .pf-port itself
    // For port index 1+ (left/right), data-port is on .pf-port-row wrapper
    const selector =
      portIndex === 0
        ? `[data-node="${nodeId}"] .pf-port[data-port="${portType}-${portIndex}"]`
        : `[data-node="${nodeId}"] [data-port="${portType}-${portIndex}"] .pf-port`;

    const portElement = document.querySelector(selector);

    if (portElement) {
      const nodeElement = portElement.closest('.pf-node') as HTMLElement | null;
      if (nodeElement !== null) {
        // Check if node is in a dock chain (flexbox positioning)
        const chainContainer = nodeElement.closest(
          '.pf-node-wrapper',
        ) as HTMLElement | null;

        let nodeLeft: number;
        let nodeTop: number;

        if (chainContainer) {
          // Node is in a dock chain - use container's position
          nodeLeft = parseFloat(chainContainer.style.left) || 0;
          nodeTop = parseFloat(chainContainer.style.top) || 0;

          // Add offset of node within the chain
          const chainRect = chainContainer.getBoundingClientRect();
          const nodeRect = nodeElement.getBoundingClientRect();
          const offsetY = (nodeRect.top - chainRect.top) / canvasState.zoom;

          nodeTop += offsetY;
        } else {
          // Standalone node - use its position directly
          nodeLeft = parseFloat(nodeElement.style.left) || 0;
          nodeTop = parseFloat(nodeElement.style.top) || 0;
        }

        // Get port's position relative to the node
        const portRect = portElement.getBoundingClientRect();
        const nodeRect = nodeElement.getBoundingClientRect();

        // Calculate offset in screen space, then divide by zoom to get canvas content space
        const portX =
          (portRect.left - nodeRect.left + portRect.width / 2) /
          canvasState.zoom;
        const portY =
          (portRect.top - nodeRect.top + portRect.height / 2) /
          canvasState.zoom;

        return {
          x: nodeLeft + portX,
          y: nodeTop + portY,
        };
      }
    }

    return {x: 0, y: 0};
  }

  // Find if dragged node is in dock zone of any node
  function findDockTarget(
    draggedNode: Node,
    draggedX: number,
    draggedY: number,
    nodes: ReadonlyArray<Node>,
  ): {targetNodeId: string | null; isValidZone: boolean} {
    const DOCK_DISTANCE = 30;
    const HORIZONTAL_TOLERANCE = 100;

    // Check if dragged node can be docked at the top
    if (!draggedNode.canDockTop) {
      return {targetNodeId: null, isValidZone: false};
    }

    const draggedPos = {x: draggedX, y: draggedY};

    for (const node of nodes) {
      if (node.id === draggedNode.id) continue;

      // Find the last node in this chain
      let lastInChain: Node | Omit<Node, 'x' | 'y'> = node;
      while (lastInChain.next) {
        lastInChain = lastInChain.next;
      }

      // Check if last node in chain allows docking below it
      if (!lastInChain.canDockBottom) {
        continue; // Skip this node as a dock target
      }

      const nodePos = {x: node.x, y: node.y};
      const lastDims = getNodeDimensions(lastInChain.id);

      // Calculate position of last node in chain
      let chainHeight = 0;
      let current: Node | Omit<Node, 'x' | 'y'> = node;
      while (current !== lastInChain) {
        chainHeight += getNodeDimensions(current.id).height;
        current = current.next!;
      }

      const nodeBottom = nodePos.y + chainHeight + lastDims.height;

      const verticalDist = draggedPos.y - nodeBottom;
      const isBelow = verticalDist >= -10 && verticalDist <= DOCK_DISTANCE;

      const draggedDims = getNodeDimensions(draggedNode.id);
      const nodeDims = getNodeDimensions(node.id);
      const horizontalDist = Math.abs(
        nodePos.x + nodeDims.width / 2 - (draggedPos.x + draggedDims.width / 2),
      );
      const isAligned = horizontalDist <= HORIZONTAL_TOLERANCE;

      if (isBelow && isAligned) {
        // Return the ID of the LAST node in the chain
        return {targetNodeId: lastInChain.id, isValidZone: true};
      }
    }

    return {targetNodeId: null, isValidZone: false};
  }

  function getNodeDimensions(nodeId: string): {width: number; height: number} {
    const nodeElement = document.querySelector(`[data-node="${nodeId}"]`);
    if (nodeElement) {
      const rect = nodeElement.getBoundingClientRect();
      // Divide by zoom to get canvas content space dimensions
      return {
        width: rect.width / canvasState.zoom,
        height: rect.height / canvasState.zoom,
      };
    }
    // Fallback if DOM element not found
    return {width: 180, height: 100};
  }

  function checkNodeOverlap(
    x: number,
    y: number,
    nodeId: string,
    nodes: ReadonlyArray<Node>,
    nodeWidth: number,
    nodeHeight: number,
  ): boolean {
    const padding = 10;

    for (const node of nodes) {
      if (node.id === nodeId) continue; // Don't check against self

      // Get dimensions of the node we're checking against
      const otherDims = getNodeDimensions(node.id);

      // Calculate total height of the other node's chain
      const chain = getChain(node);
      let otherChainHeight = 0;
      chain.forEach((chainNode) => {
        otherChainHeight += getNodeDimensions(chainNode.id).height;
      });

      const overlaps = !(
        x + nodeWidth + padding < node.x ||
        x > node.x + otherDims.width + padding ||
        y + nodeHeight + padding < node.y ||
        y > node.y + otherChainHeight + padding
      );

      if (overlaps) return true;
    }
    return false;
  }

  function findNearestNonOverlappingPosition(
    startX: number,
    startY: number,
    nodeId: string,
    nodes: ReadonlyArray<Node>,
    nodeWidth: number,
    nodeHeight: number,
  ): Position {
    // If no overlap at current position, return it
    if (
      !checkNodeOverlap(startX, startY, nodeId, nodes, nodeWidth, nodeHeight)
    ) {
      return {x: startX, y: startY};
    }

    // Search in a spiral pattern for a non-overlapping position
    const step = 20; // Step size for searching
    const maxRadius = 500; // Maximum search radius

    for (let radius = step; radius <= maxRadius; radius += step) {
      // Try positions in a circle around the original position
      const numSteps = Math.ceil((2 * Math.PI * radius) / step);

      for (let i = 0; i < numSteps; i++) {
        const angle = (2 * Math.PI * i) / numSteps;
        const x = Math.round(startX + radius * Math.cos(angle));
        const y = Math.round(startY + radius * Math.sin(angle));

        if (!checkNodeOverlap(x, y, nodeId, nodes, nodeWidth, nodeHeight)) {
          return {x, y};
        }
      }
    }

    // Fallback: return original position if no free space found
    return {x: startX, y: startY};
  }

  function getNodesBoundingBox(
    nodes: ReadonlyArray<Node>,
    includeChains: boolean,
  ): {minX: number; minY: number; maxX: number; maxY: number} {
    if (nodes.length === 0) {
      return {minX: 0, minY: 0, maxX: 0, maxY: 0};
    }

    let minX = Infinity;
    let minY = Infinity;
    let maxX = -Infinity;
    let maxY = -Infinity;

    nodes.forEach((node) => {
      const dims = getNodeDimensions(node.id);
      minX = Math.min(minX, node.x);
      minY = Math.min(minY, node.y);
      maxX = Math.max(maxX, node.x + dims.width);

      if (includeChains) {
        const chain = getChain(node);
        let chainHeight = 0;
        chain.forEach((chainNode) => {
          const chainDims = getNodeDimensions(chainNode.id);
          chainHeight += chainDims.height;
        });
        maxY = Math.max(maxY, node.y + chainHeight);
      } else {
        maxY = Math.max(maxY, node.y + dims.height);
      }
    });

    return {minX, minY, maxX, maxY};
  }

  // Helper to perform auto-layout
  function autoLayoutGraph(
    nodes: ReadonlyArray<Node>,
    connections: ReadonlyArray<Connection>,
    onNodeMove: ((nodeId: string, x: number, y: number) => void) | undefined,
  ) {
    // Build a map from any node ID (including nodes in chains) to its root node ID
    const nodeIdToRootId = new Map<string, string>();
    nodes.forEach((node) => {
      nodeIdToRootId.set(node.id, node.id);
      const chain = getChain(node);
      chain.slice(1).forEach((chainNode) => {
        nodeIdToRootId.set(chainNode.id, node.id);
      });
    });

    // Find root nodes (nodes with no incoming connections)
    // Count connections to any node in a chain as connections to the root
    const incomingCounts = new Map<string, number>();
    nodes.forEach((node) => incomingCounts.set(node.id, 0));
    connections.forEach((conn) => {
      const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode;
      const currentCount = incomingCounts.get(rootId) ?? 0;
      incomingCounts.set(rootId, currentCount + 1);
    });

    const rootNodes = nodes.filter((node) => incomingCounts.get(node.id) === 0);
    const visited = new Set<string>();
    const layers: string[][] = [];

    // BFS to assign nodes to layers
    const queue: Array<{id: string; layer: number}> = rootNodes.map((n) => ({
      id: n.id,
      layer: 0,
    }));

    while (queue.length > 0) {
      const {id, layer} = queue.shift()!;
      if (visited.has(id)) continue;
      visited.add(id);

      if (layers[layer] === undefined) layers[layer] = [];
      layers[layer].push(id);

      // Add connected nodes to next layer
      // If connection goes to a node in a chain, add the root node
      connections
        .filter((conn) => {
          // Check if this node or any node in its chain is the source
          const node = nodes.find((n) => n.id === id);
          if (!node) return false;
          const chain = getChain(node);
          return chain.some((chainNode) => chainNode.id === conn.fromNode);
        })
        .forEach((conn) => {
          const rootId = nodeIdToRootId.get(conn.toNode) ?? conn.toNode;
          if (!visited.has(rootId)) {
            queue.push({id: rootId, layer: layer + 1});
          }
        });
    }

    // Position nodes using actual DOM dimensions
    const layerSpacing = 50; // Horizontal spacing between layers
    let currentX = 50; // Start position

    layers.forEach((layer) => {
      // Find the widest node in this layer (considering entire chains)
      let maxWidth = 0;
      layer.forEach((nodeId) => {
        const node = nodes.find((n) => n.id === nodeId);
        if (node) {
          // Check width of all nodes in the chain
          const chain = getChain(node);
          chain.forEach((chainNode) => {
            const chainDims = getNodeDimensions(chainNode.id);
            maxWidth = Math.max(maxWidth, chainDims.width);
          });
        }
      });

      // Position each node in this layer
      let currentY = 50;
      layer.forEach((nodeId) => {
        const node = nodes.find((n) => n.id === nodeId);
        if (node && onNodeMove) {
          onNodeMove(node.id, currentX, currentY);

          // Calculate height of entire chain
          const chain = getChain(node);
          let chainHeight = 0;
          chain.forEach((chainNode) => {
            const dims = getNodeDimensions(chainNode.id);
            chainHeight += dims.height;
          });

          currentY += chainHeight + 30;
        }
      });

      // Move to next layer
      currentX += maxWidth + layerSpacing;
    });

    m.redraw();
  }

  function autofit(nodes: ReadonlyArray<Node>, canvas: HTMLElement) {
    if (nodes.length === 0) return;

    const {minX, minY, maxX, maxY} = getNodesBoundingBox(nodes, true);

    // Calculate bounding box dimensions
    const boundingWidth = maxX - minX;
    const boundingHeight = maxY - minY;

    // Get canvas dimensions
    const canvasRect = canvas.getBoundingClientRect();

    // Calculate zoom to fit with buffer (10% padding)
    const bufferFactor = 0.9; // Use 90% of viewport to leave 10% buffer
    const zoomX = (canvasRect.width * bufferFactor) / boundingWidth;
    const zoomY = (canvasRect.height * bufferFactor) / boundingHeight;
    const newZoom = Math.max(0.1, Math.min(5.0, Math.min(zoomX, zoomY)));

    // Calculate the scaled bounding box dimensions
    const scaledWidth = boundingWidth * newZoom;
    const scaledHeight = boundingHeight * newZoom;

    // Calculate pan offset to center the bounding box with equal padding on all sides
    const paddingX = (canvasRect.width - scaledWidth) / 2;
    const paddingY = (canvasRect.height - scaledHeight) / 2;

    canvasState.zoom = newZoom;
    canvasState.panOffset = {
      x: paddingX - minX * newZoom,
      y: paddingY - minY * newZoom,
    };

    m.redraw();
  }

  const handleWheel = (e: WheelEvent) => {
    if (!canvasElement) return;
    e.preventDefault();

    // Zoom with Ctrl+wheel, pan without Ctrl
    if (e.ctrlKey || e.metaKey) {
      // Zoom around mouse position
      const canvas = canvasElement;
      const canvasRect = canvas.getBoundingClientRect();
      const mouseX = e.clientX - canvasRect.left;
      const mouseY = e.clientY - canvasRect.top;

      // Calculate zoom delta (negative deltaY = zoom in)
      const zoomDelta = -e.deltaY * 0.003;
      const newZoom = Math.max(
        0.1,
        Math.min(5.0, canvasState.zoom * (1 + zoomDelta)),
      );

      // Calculate the point in canvas space (before zoom)
      const canvasX = (mouseX - canvasState.panOffset.x) / canvasState.zoom;
      const canvasY = (mouseY - canvasState.panOffset.y) / canvasState.zoom;

      // Update zoom
      canvasState.zoom = newZoom;

      // Adjust pan to keep the same point under the mouse
      canvasState.panOffset = {
        x: mouseX - canvasX * newZoom,
        y: mouseY - canvasY * newZoom,
      };
    } else {
      // Pan the canvas based on wheel delta
      canvasState.panOffset = {
        x: canvasState.panOffset.x - e.deltaX,
        y: canvasState.panOffset.y - e.deltaY,
      };
    }

    m.redraw();
  };

  // Helper function to render a single node
  function renderNode(
    node: Node | Omit<Node, 'x' | 'y'>,
    vnode: m.Vnode<NodeGraphAttrs>,
    options: {
      isDockedChild: boolean;
      hasDockedChild: boolean;
      isDockTarget: boolean;
      rootNode?: Node;
      multiselect: boolean;
      contextMenuOnHover: boolean;
    },
  ): m.Vnode {
    const {
      id,
      inputs = [],
      outputs = [],
      titleBar,
      content,
      hue,
      accentBar,
      contextMenuItems,
      invalid,
    } = node;
    const {
      isDockedChild,
      hasDockedChild,
      isDockTarget,
      rootNode,
      multiselect,
      contextMenuOnHover,
    } = options;
    const {connections = [], onConnect, nodes = []} = vnode.attrs;

    // Separate ports by direction
    const topInputs = inputs.filter((p) => p.direction === 'top');
    const leftInputs = inputs.filter((p) => p.direction === 'left');
    const bottomOutputs = outputs.filter((p) => p.direction === 'bottom');
    const rightOutputs = outputs.filter((p) => p.direction === 'right');

    const classes = classNames(
      canvasState.selectedNodes.has(id) && 'pf-selected',
      isDockedChild && 'pf-docked-child',
      hasDockedChild && 'pf-has-docked-child',
      isDockTarget && 'pf-dock-target',
      accentBar && 'pf-node--has-accent-bar',
      invalid && 'pf-invalid',
    );

    // Helper to render a port
    const renderPort = (
      port: NodePort,
      portIndex: number,
      portType: 'input' | 'output',
      forceConnected?: boolean,
    ) => {
      const portId = `${portType}-${portIndex}`;
      const cssClass = classNames(
        portType === 'input' ? 'pf-input' : 'pf-output',
        `pf-port-${port.direction}`,
        (forceConnected ||
          isPortConnected(id, portType, portIndex, connections)) &&
          'pf-connected',
        canvasState.connecting &&
          canvasState.connecting.nodeId === id &&
          canvasState.connecting.portIndex === portIndex &&
          canvasState.connecting.type === portType &&
          'pf-active',
        port.contextMenuItems !== undefined && 'pf-port--with-context-menu',
      );

      const portElement = m('.pf-port', {
        'data-port': portId,
        'className': cssClass,
        'onpointerdown': (e: PointerEvent) => {
          e.stopPropagation();
          if (portType === 'input') {
            // Input port - check for existing connection
            const existingConnIdx = connections.findIndex(
              (conn) => conn.toNode === id && conn.toPort === portIndex,
            );
            if (existingConnIdx !== -1) {
              const existingConn = connections[existingConnIdx];
              const {onConnectionRemove} = vnode.attrs;
              if (onConnectionRemove !== undefined) {
                onConnectionRemove(existingConnIdx);
              }
              const outputPos = getPortPosition(
                existingConn.fromNode,
                'output',
                existingConn.fromPort,
              );
              canvasState.connecting = {
                nodeId: existingConn.fromNode,
                portIndex: existingConn.fromPort,
                type: 'output',
                portType: getPortType(
                  existingConn.fromNode,
                  'output',
                  existingConn.fromPort,
                  nodes,
                ),
                x: 0,
                y: 0,
                transformedX: outputPos.x,
                transformedY: outputPos.y,
              };
              m.redraw();
            }
          } else {
            // Output port - start connection
            const portPos = getPortPosition(id, portType, portIndex);
            canvasState.connecting = {
              nodeId: id,
              portIndex,
              type: portType,
              portType: port.direction,
              x: 0,
              y: 0,
              transformedX: portPos.x,
              transformedY: portPos.y,
            };
          }
        },
        'onpointerup': (e: PointerEvent) => {
          e.stopPropagation();
          if (portType === 'input') {
            if (
              canvasState.connecting &&
              canvasState.connecting.type === 'output'
            ) {
              // Input port receiving connection
              const existingConnIdx = connections.findIndex(
                (conn) => conn.toNode === id && conn.toPort === portIndex,
              );
              if (existingConnIdx !== -1) {
                const {onConnectionRemove} = vnode.attrs;
                if (onConnectionRemove !== undefined) {
                  onConnectionRemove(existingConnIdx);
                }
              }
              const connection = {
                fromNode: canvasState.connecting.nodeId,
                fromPort: canvasState.connecting.portIndex,
                toNode: id,
                toPort: portIndex,
              };
              if (onConnect !== undefined) {
                onConnect(connection);
              }
              canvasState.connecting = null;
            }
          } else if (portType === 'output') {
            // Clear connecting state if releasing on output port without completing connection
            canvasState.connecting = null;
          }
        },
      });

      // Wrap with PopupMenu if contextMenuItems exist
      if (port.contextMenuItems !== undefined) {
        return m(PopupMenu, {trigger: portElement}, port.contextMenuItems);
      }
      return portElement;
    };

    const style = hue !== undefined ? {'--pf-node-hue': `${hue}`} : undefined;

    return m(
      '.pf-node',
      {
        'key': id,
        'data-node': id,
        'class': classes,
        'style': {
          ...style,
        },
        'onpointerdown': (e: PointerEvent) => {
          if ((e.target as HTMLElement).closest('.pf-port')) {
            return;
          }
          e.stopPropagation();

          // Handle multi-selection with Shift or Cmd/Ctrl (only if multiselect is enabled)
          if (multiselect && (e.shiftKey || e.metaKey || e.ctrlKey)) {
            // Toggle selection
            if (canvasState.selectedNodes.has(id)) {
              const {onNodeRemoveFromSelection} = vnode.attrs;
              if (onNodeRemoveFromSelection !== undefined) {
                onNodeRemoveFromSelection(id);
              }
            } else {
              const {onNodeAddToSelection} = vnode.attrs;
              if (onNodeAddToSelection !== undefined) {
                onNodeAddToSelection(id);
              }
            }
            return;
          }

          // Check if this is a chained node (not root)
          if (isDockedChild && rootNode) {
            // Don't undock immediately - wait for drag threshold
            // Calculate current render position
            let yOffset = rootNode.y;
            const chainArr = getChain(rootNode);
            for (const cn of chainArr) {
              if (cn.id === id) break;
              yOffset += getNodeDimensions(cn.id).height;
            }

            // Find parent node in chain
            let parentId = rootNode.id;
            let curr = rootNode.next;
            while (curr && curr.id !== id) {
              parentId = curr.id;
              curr = curr.next;
            }

            // Store undock candidate - will undock if dragged beyond threshold
            canvasState.undockCandidate = {
              nodeId: id,
              parentId: parentId,
              startX: e.clientX,
              startY: e.clientY,
              renderY: yOffset,
            };
          }

          canvasState.draggedNode = id;

          // Store initial drag position for batching
          // Check if node has x,y properties (root nodes) vs docked children (no x,y)
          if ('x' in node && 'y' in node) {
            dragStartPosition = {nodeId: id, x: node.x, y: node.y};
            currentDragPosition = {x: node.x, y: node.y};
          }

          const {onNodeSelect} = vnode.attrs;
          if (onNodeSelect !== undefined) {
            onNodeSelect(id);
          }

          const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
          canvasState.dragOffset = {
            x: e.clientX - rect.left,
            y: e.clientY - rect.top,
          };
        },
      },
      [
        // Render node title if it exists
        titleBar !== undefined &&
          m('.pf-node-header', [
            m('.pf-node-title', titleBar.title),
            contextMenuItems !== undefined &&
              m(
                PopupMenu,
                {
                  trigger: m(Button, {
                    rounded: true,
                    icon: Icons.ContextMenuAlt,
                    className: contextMenuOnHover ? 'pf-show-on-hover' : '',
                  }),
                },
                contextMenuItems,
              ),
          ]),

        // Context menu button for nodes without titlebar
        titleBar === undefined &&
          contextMenuItems !== undefined &&
          m(
            '.pf-node-context-menu',
            {className: contextMenuOnHover ? 'pf-show-on-hover' : ''},
            m(
              PopupMenu,
              {
                trigger: m(Button, {
                  rounded: true,
                  icon: Icons.ContextMenuAlt,
                }),
              },
              contextMenuItems,
            ),
          ),

        // Top input ports (if not docked child)
        topInputs.map((port) => {
          const portIndex = inputs.indexOf(port);
          return renderPort(port, portIndex, 'input');
        }),

        m('.pf-node-body', [
          content !== undefined &&
            m(
              '.pf-node-content',
              {
                onkeydown: (e: KeyboardEvent) => {
                  e.stopPropagation();
                },
              },
              content,
            ),

          // Left input ports
          leftInputs.map((port) => {
            const portIndex = inputs.indexOf(port);
            return m(
              '.pf-port-row.pf-port-input',
              {
                'data-port': `input-${portIndex}`,
              },
              [renderPort(port, portIndex, 'input'), port.content],
            );
          }),

          // Right output ports
          rightOutputs.map((port) => {
            const portIndex = outputs.indexOf(port);
            return m(
              '.pf-port-row.pf-port-output',
              {
                'data-port': `output-${portIndex}`,
              },
              [port.content, renderPort(port, portIndex, 'output')],
            );
          }),
        ]),

        // Bottom output ports (if no docked child below)
        bottomOutputs.map((port) => {
          const portIndex = outputs.indexOf(port);
          return renderPort(port, portIndex, 'output');
        }),
      ],
    );
  }

  return {
    oncreate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
      latestVnode = vnode;
      canvasElement = vnode.dom as HTMLElement;
      document.addEventListener('pointermove', handleMouseMove);
      document.addEventListener('pointerup', handleMouseUp);
      canvasElement.addEventListener('wheel', handleWheel, {passive: false});

      const {connections, nodes, onConnectionRemove, onReady} = vnode.attrs;

      // Render connections after DOM is ready
      const svg = vnode.dom.querySelector('svg');
      if (svg) {
        renderConnections(
          svg as SVGElement,
          connections,
          nodes,
          onConnectionRemove,
        );
      }

      // Create auto-layout function that uses actual DOM dimensions
      const autoLayout = () => {
        const {nodes = [], connections = [], onNodeMove} = vnode.attrs;
        autoLayoutGraph(nodes, connections, onNodeMove);
      };

      // Create recenter function that brings all nodes into view
      const recenter = () => {
        const {nodes = []} = vnode.attrs;
        const canvas = vnode.dom as HTMLElement;
        autofit(nodes, canvas);
      };

      // Find a non-overlapping position for a new node
      const findPlacementForNode = (
        newNode: Omit<Node, 'x' | 'y'>,
      ): Position => {
        if (latestVnode === null || canvasElement === null) {
          return {x: 0, y: 0};
        }

        const {nodes = []} = latestVnode.attrs;
        const canvas = canvasElement;

        // Default starting position (center of viewport in canvas space)
        const canvasRect = canvas.getBoundingClientRect();
        const centerX =
          (canvasRect.width / 2 - canvasState.panOffset.x) / canvasState.zoom;
        const centerY =
          (canvasRect.height / 2 - canvasState.panOffset.y) / canvasState.zoom;

        // Create a temporary node with coordinates to render and measure
        const tempNode: Node = {
          ...newNode,
          x: centerX,
          y: centerY,
        };

        // Create temporary DOM element to measure size
        const tempContainer = document.createElement('div');
        tempContainer.style.position = 'absolute';
        tempContainer.style.left = '-9999px';
        tempContainer.style.visibility = 'hidden';
        canvas.appendChild(tempContainer);

        // Render the node into the temporary container
        m.render(
          tempContainer,
          m(
            '.pf-node',
            {
              'data-node': tempNode.id,
              'style': {
                ...(tempNode.hue !== undefined
                  ? {'--pf-node-hue': `${tempNode.hue}`}
                  : {}),
              },
            },
            [
              tempNode.titleBar &&
                m('.pf-node-header', [
                  m('.pf-node-title', tempNode.titleBar.title),
                ]),
              m('.pf-node-body', [
                tempNode.content !== undefined &&
                  m('.pf-node-content', tempNode.content),
                tempNode.inputs
                  ?.filter((p) => p.direction === 'left')
                  .map((port) =>
                    m('.pf-port-row.pf-port-input', [
                      m('.pf-port'),
                      port.content,
                    ]),
                  ),
                tempNode.outputs
                  ?.filter((p) => p.direction === 'right')
                  .map((port) =>
                    m('.pf-port-row.pf-port-output', [
                      port.content,
                      m('.pf-port'),
                    ]),
                  ),
              ]),
            ],
          ),
        );

        // Get dimensions from the rendered element
        const dims = getNodeDimensions(tempNode.id);

        // Calculate chain height
        const chain = getChain(tempNode);
        let chainHeight = 0;
        chain.forEach((chainNode) => {
          const chainDims = getNodeDimensions(chainNode.id);
          chainHeight += chainDims.height;
        });

        // Clean up temporary element
        canvas.removeChild(tempContainer);

        // Find non-overlapping position starting from center
        const finalPos = findNearestNonOverlappingPosition(
          centerX - dims.width / 2,
          centerY - dims.height / 2,
          tempNode.id,
          nodes,
          dims.width,
          chainHeight,
        );

        return finalPos;
      };

      // Provide API to parent
      if (onReady) {
        onReady({autoLayout, recenter, findPlacementForNode});
      }
    },

    onupdate: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
      latestVnode = vnode;
      const {connections = [], nodes = [], onConnectionRemove} = vnode.attrs;

      // Re-render connections when component updates
      const svg = vnode.dom.querySelector('svg');
      if (svg) {
        renderConnections(
          svg as SVGElement,
          connections,
          nodes,
          onConnectionRemove,
        );
      }
    },

    onremove: (vnode: m.VnodeDOM<NodeGraphAttrs>) => {
      document.removeEventListener('pointermove', handleMouseMove);
      document.removeEventListener('pointerup', handleMouseUp);
      (vnode.dom as HTMLElement).removeEventListener('wheel', handleWheel);
    },

    view: (vnode: m.Vnode<NodeGraphAttrs>) => {
      latestVnode = vnode;
      const {
        nodes,
        selectedNodeIds = new Set<string>(),
        hideControls = false,
        multiselect = true,
        contextMenuOnHover = false,
        fillHeight,
      } = vnode.attrs;

      // Sync internal state with prop
      canvasState.selectedNodes = selectedNodeIds;

      const className = classNames(
        fillHeight && 'pf-canvas--fill-height',
        canvasState.connecting && 'pf-connecting',
        canvasState.connecting &&
          `connecting-from-${canvasState.connecting.type}`,
        canvasState.isPanning && 'pf-panning',
      );

      return m(
        '.pf-canvas',
        {
          className,
          tabindex: 0, // Make div focusable to capture keyboard events
          oncontextmenu: (e: Event) => {
            e.preventDefault(); // Disable default context menu
          },
          onpointerdown: (e: PointerEvent) => {
            const target = e.target as HTMLElement;
            if (
              target.classList.contains('pf-canvas') ||
              target.tagName === 'svg'
            ) {
              // Start box selection with Shift (only if multiselect is enabled)
              if (multiselect && e.shiftKey) {
                const transformedX = canvasState.mousePos.transformedX ?? 0;
                const transformedY = canvasState.mousePos.transformedY ?? 0;
                canvasState.selectionRect = {
                  startX: transformedX,
                  startY: transformedY,
                  currentX: transformedX,
                  currentY: transformedY,
                };
                return;
              }

              // Start panning and store position to detect click vs drag
              canvasState.isPanning = true;
              canvasState.panStart = {x: e.clientX, y: e.clientY};
              canvasState.canvasMouseDownPos = {x: e.clientX, y: e.clientY};
            }
          },
          onclick: (e: PointerEvent) => {
            const target = e.target as HTMLElement;
            // Clear selection on canvas click (only if mouse didn't move significantly)
            if (
              target.classList.contains('pf-canvas') ||
              target.tagName === 'svg'
            ) {
              const dx = Math.abs(e.clientX - canvasState.canvasMouseDownPos.x);
              const dy = Math.abs(e.clientY - canvasState.canvasMouseDownPos.y);
              const threshold = 3; // Pixels of movement tolerance

              // Only clear if it was a click (not a drag)
              if (dx <= threshold && dy <= threshold) {
                const {onSelectionClear} = vnode.attrs;
                if (onSelectionClear !== undefined) {
                  onSelectionClear();
                }
              }
            }
          },
          onkeydown: (e: KeyboardEvent) => {
            if (e.key === 'Escape') {
              // Deselect all nodes with Escape key
              if (canvasState.selectedNodes.size > 0) {
                const {onSelectionClear} = vnode.attrs;
                if (onSelectionClear !== undefined) {
                  onSelectionClear();
                }
              }
            } else if (e.key === 'Delete' || e.key === 'Backspace') {
              const {onNodeRemove} = vnode.attrs;
              if (canvasState.selectedNodes.size > 0 && onNodeRemove) {
                // Delete all selected nodes
                canvasState.selectedNodes.forEach((nodeId) => {
                  onNodeRemove(nodeId);
                });
              }
            }
          },
          style: {
            backgroundSize: `${20 * canvasState.zoom}px ${20 * canvasState.zoom}px`,
            backgroundPosition: `${canvasState.panOffset.x}px ${canvasState.panOffset.y}px`,
            ...vnode.attrs.style,
          },
        },
        [
          // Control buttons (can be hidden via hideControls prop)
          !hideControls &&
            m('.pf-nodegraph-controls', [
              vnode.attrs.toolbarItems,
              m(Button, {
                label: 'Auto Layout',
                icon: 'account_tree',
                variant: ButtonVariant.Filled,
                onclick: () => {
                  const {
                    nodes = [],
                    connections = [],
                    onNodeMove,
                  } = vnode.attrs;
                  autoLayoutGraph(nodes, connections, onNodeMove);
                },
              }),
              m(Button, {
                label: 'Fit to Screen',
                icon: 'center_focus_strong',
                variant: ButtonVariant.Filled,
                onclick: (e: PointerEvent) => {
                  const {nodes = []} = vnode.attrs;
                  const canvas = (e.currentTarget as HTMLElement).closest(
                    '.pf-canvas',
                  );
                  if (canvas) {
                    autofit(nodes, canvas as HTMLElement);
                  }
                },
              }),
            ]),

          // Container for nodes and SVG that gets transformed
          m(
            '.pf-canvas-content',
            {
              style: `transform: translate(${canvasState.panOffset.x}px, ${canvasState.panOffset.y}px) scale(${canvasState.zoom}); transform-origin: 0 0;`,
            },
            [
              // SVG container for connections (rendered imperatively in oncreate/onupdate)
              m('svg'),

              // Selection rectangle overlay
              canvasState.selectionRect &&
                m('.pf-selection-rect', {
                  style: {
                    left: `${Math.min(canvasState.selectionRect.startX, canvasState.selectionRect.currentX)}px`,
                    top: `${Math.min(canvasState.selectionRect.startY, canvasState.selectionRect.currentY)}px`,
                    width: `${Math.abs(canvasState.selectionRect.currentX - canvasState.selectionRect.startX)}px`,
                    height: `${Math.abs(canvasState.selectionRect.currentY - canvasState.selectionRect.startY)}px`,
                  },
                }),

              // Render all nodes - wrap dock chains in flex container
              nodes
                .map((node: Node) => {
                  const {id} = node;

                  // Check if this is the root of a dock chain
                  const chain = getChain(node);
                  const isChainRoot = chain.length > 1;

                  // Check if we have a temp position for this node (during drag)
                  const tempPos = canvasState.tempNodePositions.get(id);
                  const renderPos = tempPos || {x: node.x, y: node.y};

                  // If this is a chain root, wrap all chain nodes in flex container
                  // Always wrap in a chain root container for consistency

                  if (isChainRoot) {
                    return m(
                      '.pf-node-wrapper',
                      {
                        key: `chain-${id}`,
                        style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`,
                        className: classNames(
                          canvasState.draggedNode === id &&
                            'pf-node-wrapper--dragging',
                        ),
                      },
                      chain.map((chainNode) => {
                        const cIsDockedChild = 'x' in chainNode === false;
                        const cHasDockedChild = chainNode.next !== undefined;
                        const cIsDockTarget =
                          canvasState.dockTarget === chainNode.id &&
                          canvasState.isDockZone;

                        return renderNode(chainNode, vnode, {
                          isDockedChild: cIsDockedChild,
                          hasDockedChild: cHasDockedChild,
                          isDockTarget: cIsDockTarget,
                          rootNode: node,
                          multiselect,
                          contextMenuOnHover,
                        });
                      }),
                    );
                  } else {
                    // Render standalone node (not part of a chain)
                    const isDockTarget =
                      canvasState.dockTarget === id && canvasState.isDockZone;

                    return m(
                      '.pf-node-wrapper',
                      {
                        key: `chain-${id}`,
                        style: `left: ${renderPos.x}px; top: ${renderPos.y}px;`,
                        className: classNames(
                          canvasState.draggedNode === id &&
                            'pf-node-wrapper--dragging',
                        ),
                      },
                      renderNode(node, vnode, {
                        isDockedChild: false,
                        hasDockedChild: false,
                        isDockTarget,
                        rootNode: undefined,
                        multiselect,
                        contextMenuOnHover,
                      }),
                    );
                  }
                })
                .filter((vnode) => vnode !== null),
            ],
          ),
        ],
      );
    },
  };
}
